import type {
  InvalidTestCase,
  ValidTestCase,
} from '@typescript-eslint/rule-tester';
import type { TSESLint } from '@typescript-eslint/utils';
import { compile } from 'json-schema-to-typescript';
import traverse from 'json-schema-traverse';
import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { join, relative } from 'node:path';
import { format, resolveConfig } from 'prettier';
import ts from 'typescript';

// Import directly from source for this utility script
import { SPECIAL_UNDERLINE_CHARS } from '../../packages/test-utils/src/convert-annotated-source-to-failure-case';

const plugin = process.argv[2];

if (plugin !== 'eslint-plugin-template' && plugin !== 'eslint-plugin') {
  console.error(
    `\nError: the first argument to the script must be "eslint-plugin-template" or "eslint-plugin"`,
  );
  process.exit(1);
}

const docsOutputDir = join(__dirname, `../../packages/${plugin}/docs/rules`);

const rulesDir = join(__dirname, `../../packages/${plugin}/src/rules`);
const ruleFiles = readdirSync(rulesDir);

const testDirsDir = join(__dirname, `../../packages/${plugin}/tests/rules`);
const testDirs = readdirSync(testDirsDir);

(async function main() {
  const allRuleData = await generateAllRuleData();
  // Delete existing docs to ensure that any deleted ones do not get left behind
  try {
    rmSync(docsOutputDir, { recursive: true, force: true });
  } catch {}
  mkdirSync(docsOutputDir, { recursive: true });

  for (const [ruleName, ruleData] of Object.entries(allRuleData)) {
    const {
      ruleConfig: {
        meta: { deprecated, replacedBy, type, fixable, schema, hasSuggestions },
        defaultOptions,
      },
      docsExtension,
      ruleFilePath,
      testCasesFilePath,
    } = ruleData;

    const docs = ruleData.ruleConfig.meta.docs!;
    const { description } = docs;

    let schemaAsInterface = '';
    if (Array.isArray(schema) && schema[0]) {
      /**
       * json-schema-to-typescript does not do anything with the "default" property,
       * but it's really useful to include in the documentation, so we apply the
       * default value in a consistent way to the "description" property before
       * converting.
       */
      traverse(schema[0], {
        allKeys: true,
        cb: (...data) => {
          const [schemaNode, , , , , , keyIndex] = data;

          let defaultValue = undefined;
          let hasDefaultValue = false;

          if (typeof schemaNode.default !== 'undefined') {
            defaultValue = schemaNode.default;
            hasDefaultValue = true;
          } else if (defaultOptions?.length) {
            for (const defaultOption of defaultOptions) {
              if (
                typeof defaultOption === 'object' &&
                (keyIndex as string) in defaultOption
              ) {
                defaultValue = defaultOption[keyIndex as string];
                hasDefaultValue = true;
              }
            }
          }

          if (hasDefaultValue) {
            if (schemaNode.description) {
              schemaNode.description += '\n\n';
            } else {
              schemaNode.description = '';
            }
            const serializedDefaultValue = JSON.stringify(defaultValue);
            schemaNode.description += `Default: \`${serializedDefaultValue}\``;
            return;
          }
        },
      });
      schemaAsInterface = await compile(schema[0], 'Options', {
        bannerComment: '',
      });
      schemaAsInterface = schemaAsInterface.replace('export ', '');
    }

    const fullRuleName = `@angular-eslint/${
      plugin === 'eslint-plugin-template' ? 'template/' : ''
    }${ruleName}`;

    const md = `
<!--

  DO NOT EDIT.

  This markdown file was autogenerated using a mixture of the following files as the source of truth for its data:
  - ${relativePath(docsOutputDir, ruleFilePath)}
  - ${relativePath(docsOutputDir, testCasesFilePath)}

  In order to update this file, it is therefore those files which need to be updated, as well as potentially the generator script:
  - ${relativePath(docsOutputDir, __filename)}

-->

<br>

# \`${fullRuleName}\`

${
  deprecated
    ? `## ⚠️ THIS RULE IS DEPRECATED\n\n${
        replacedBy
          ? `Please use ${(replacedBy || [])
              .map(
                (r: string) =>
                  `\`@angular-eslint/${
                    plugin === 'eslint-plugin-template' ? 'template/' : ''
                  }${r}\``,
              )
              .join(', ')} instead.`
          : ''
      }\n\n---\n\n`
    : ''
}${description}

- Type: ${type}
${fixable === 'code' ? '- 🔧 Supports autofix (`--fix`)\n' : ''}
${
  hasSuggestions
    ? '- 💡 Provides suggestions on how to fix issues (https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions)'
    : ''
}${
      docsExtension?.rationale
        ? `

<br>

## Rationale

${docsExtension.rationale}
`
        : ''
    }

<br>

## Rule Options

${
  schemaAsInterface
    ? `
The rule accepts an options object with the following properties:

\`\`\`ts
${schemaAsInterface}
\`\`\`
`
    : 'The rule does not have any configuration options.'
}

<br>

## Usage Examples

> The following examples are generated automatically from the actual unit tests within the plugin, so you can be assured that their behavior is accurate based on the current commit.

<br>

<details>
<summary>❌ - Toggle examples of <strong>incorrect</strong> code for this rule</summary>

${convertCodeExamplesToMarkdown(
  ruleData.invalid,
  'invalid',
  plugin === 'eslint-plugin-template' ? 'html' : 'ts',
  fullRuleName,
)}

</details>

<br>

---

<br>

<details>
<summary>✅ - Toggle examples of <strong>correct</strong> code for this rule</summary>

${convertCodeExamplesToMarkdown(
  ruleData.valid,
  'valid',
  plugin === 'eslint-plugin-template' ? 'html' : 'ts',
  fullRuleName,
)}

</details>

<br>
`;

    const outputFilePath = join(docsOutputDir, `${ruleName}.md`);
    writeFileSync(
      outputFilePath,
      await format(md, {
        /**
         * NOTE: In the .prettierrc we set:
         * "embeddedLanguageFormatting": "off"
         *
         * ...for these docs files as it's important we don't let prettier format the
         * code samples, because otherwise it will move the ~~~ (error highlights) to
         * the wrong locations.
         */
        ...(await resolveConfig(outputFilePath)),
        parser: 'markdown',
      }),
    );
  }

  console.log(`\n✨ Updated docs for all rules in "${plugin}"`);
})();

interface RuleData {
  ruleFilePath: string;
  testCasesFilePath: string;
  ruleConfig: TSESLint.RuleModule<string, []> & {
    defaultOptions?: Record<string, unknown>[];
  };
  // Rules can optionally export extended documentation content (outside of ESLint's concept of "docs")
  docsExtension?: {
    rationale?: string;
  };
  valid: ExtractedTestCase[];
  invalid: ExtractedTestCase[];
}

type AllRuleData = {
  [ruleName: string]: RuleData;
};

interface ExtractedTestCase {
  code: string;
  options?: unknown[];
  filename?: string;
  settings?: {
    hideFromDocs?: boolean;
  };
}

async function generateAllRuleData(): Promise<AllRuleData> {
  const ruleData: AllRuleData = {};

  // For rule sources we just import/execute the rule source file
  for (const ruleFile of ruleFiles) {
    const ruleFilePath = join(rulesDir, ruleFile.replace('.ts', ''));
    const { default: ruleConfig, RULE_NAME, RULE_DOCS_EXTENSION } = require(
      ruleFilePath,
    );
    ruleData[RULE_NAME] = {
      ruleConfig,
      ruleFilePath: ruleFilePath + '.ts',
      docsExtension: RULE_DOCS_EXTENSION,
    } as RuleData;
  }

  /**
   * For tests we want to preserve the annotated sources. We can preserve that while
   * importing the test source by setting an environment variable. This allows parameterized
   * tests to be used, which we wouldn't be able to use if we just parsed the TypeScript.
   */
  process.env.GENERATING_RULE_DOCS = '1';
  try {
    for (const testDir of testDirs) {
      const testDirPath = join(testDirsDir, testDir);
      const casesFilePath = join(testDirPath, 'cases.ts');
      const { valid, invalid } = require(casesFilePath) as {
        valid: (string | ValidTestCase<[]>)[];
        invalid: InvalidTestCase<'', []>[];
      };

      const extractedValid: ExtractedTestCase[] = valid
        .map((test) =>
          typeof test === 'string'
            ? { code: test }
            : {
                code: test.code,
                settings: test.settings,
                options:
                  test.options && test.options.length > 0
                    ? [...test.options]
                    : undefined,
                filename: test.filename,
              },
        )
        .filter((test) => test.code);

      const extractedInvalid: ExtractedTestCase[] = invalid
        .map((test) => ({
          code: test.code,
          settings: test.settings,
          options:
            test.options && test.options.length > 0
              ? [...test.options]
              : undefined,
          filename: test.filename,
        }))
        .filter((test) => test.code);

      ruleData[testDir] = {
        ...ruleData[testDir],
        testCasesFilePath: casesFilePath,
        valid: extractedValid,
        invalid: extractedInvalid,
      };
    }
  } finally {
    delete process.env.GENERATING_RULE_DOCS;
  }

  return ruleData;
}

function standardizeSpecialUnderlineChar(str: string): string {
  /**
   * It is important that we only update special characters when we are on a line
   * which only has special characters on it (as well as whitespace). Otherwise
   * we will end up replacing real characters from the source code in the example.
   */
  const specialCharsOrWhitespaceRegExp = new RegExp(
    `^(${SPECIAL_UNDERLINE_CHARS.map(escapeRegExp).join('|')}|\\s)+$`,
  );
  const whitespaceOnlyRegExp = /^\s+$/;

  return str
    .split('\n')
    .map((line) => {
      // Is line with exclusively special characters and whitespace (but not just whitespace)?
      if (
        !line.match(whitespaceOnlyRegExp) &&
        line.match(specialCharsOrWhitespaceRegExp)
      ) {
        return line
          .split('')
          .map((char) =>
            SPECIAL_UNDERLINE_CHARS.includes(
              char as (typeof SPECIAL_UNDERLINE_CHARS)[number],
            )
              ? '~'
              : char,
          )
          .join('');
      }
      return line;
    })
    .join('\n');
}

// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
function escapeRegExp(str: string) {
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}

function convertCodeExamplesToMarkdown(
  codeExamples: ExtractedTestCase[] = [],
  kind: 'valid' | 'invalid',
  highligher: 'html' | 'ts',
  ruleName: string,
): string {
  const visibleCodeExamples = codeExamples
    // Remove any test cases that are marked with the `hideFromDocs` setting
    .filter((extractedTestCase) => !extractedTestCase.settings?.hideFromDocs);

  return visibleCodeExamples
    .map((extractedTestCase: ExtractedTestCase, i) => {
      let formattedCode = removeLeadingAndTrailingEmptyLinesFromCodeExample(
        removeLeadingIndentationFromCodeExample(extractedTestCase.code),
      );
      if (kind === 'invalid') {
        formattedCode = standardizeSpecialUnderlineChar(formattedCode);
      }

      const exampleRuleConfig: unknown[] = ['error'];
      // Not all unit tests have options configured
      if (extractedTestCase.options) {
        exampleRuleConfig.push(extractedTestCase.options[0]);
      }
      const formattedConfig = JSON.stringify(
        {
          rules: {
            [ruleName]: exampleRuleConfig,
          },
        },
        null,
        2,
      );

      return `<br>

#### ${extractedTestCase.options ? 'Custom' : 'Default'} Config

\`\`\`json
${formattedConfig}
\`\`\`

<br>

#### ${kind === 'invalid' ? '❌ Invalid' : '✅ Valid'} Code

${
  extractedTestCase.filename
    ? `**Filename: ${extractedTestCase.filename}**`
    : ''
}

\`\`\`${highligher}
${formattedCode}
\`\`\`

${
  i === visibleCodeExamples.length - 1
    ? ''
    : `<br>

---`
}
  `;
    })
    .join('\n');
}

function removeLeadingAndTrailingEmptyLinesFromCodeExample(
  code: string,
): string {
  const lines = code.split('\n');

  let currentLineIndex = 0;
  let firstNonEmptyLineIndex = -1;
  let lastNonEmptyLineIndex = -1;

  for (const line of lines) {
    if (/\S/.test(line)) {
      if (firstNonEmptyLineIndex === -1) {
        firstNonEmptyLineIndex = currentLineIndex;
      }
      lastNonEmptyLineIndex = currentLineIndex;
    }
    currentLineIndex++;
  }

  return lines
    .filter((_, index) => {
      if (index < firstNonEmptyLineIndex) {
        return false;
      }
      if (index > lastNonEmptyLineIndex) {
        return false;
      }
      return true;
    })
    .join('\n');
}

/**
 * We want to remove unnecessary leading padding, but keeping everything relative,
 * so that code indentation is not messed up
 */
function removeLeadingIndentationFromCodeExample(code: string): string {
  let detectedAmountToTrim: number | null = null;

  return code
    .split('\n')
    .map((line) => {
      // Is whitespace-only line, ignore
      if (!/\S/.test(line)) {
        return line;
      }

      // Haven't yet determined the number of characters to trim from the beginning of each line
      const charsInLine = line.split('');
      if (typeof detectedAmountToTrim !== 'number') {
        let numberOfLeadingWhitespaceChars = 0;
        for (const char of charsInLine) {
          if (!/\S/.test(char)) {
            numberOfLeadingWhitespaceChars++;
            continue;
          }
          break;
        }
        detectedAmountToTrim = numberOfLeadingWhitespaceChars;
      }

      // Trim the detected number of characters from the beginning of the current line
      return (
        line
          .split('')
          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
          .filter((_, i) => !(i < detectedAmountToTrim!))
          .join('')
      );
    })
    .join('\n');
}

function convertStringLikeToCode(strLike: ts.StringLiteralLike): string {
  if (ts.isStringLiteralLike(strLike)) {
    return strLike.text;
  }
  return '';
}

function convertNumericalLiteralToCode(numLiteral: ts.NumericLiteral): number {
  return Number(numLiteral.text);
}

function convertBooleanLiteralToCode(
  booleanLiteral: ts.BooleanLiteral,
): boolean {
  const stringified = booleanLiteral.getText();
  if (stringified === 'false') {
    return false;
  }
  if (stringified === 'true') {
    return true;
  }
  throw new Error(
    `Could not convert booleanLiteral node to code: ${booleanLiteral}`,
  );
}

function convertArrayLiteralExpressionToCode(
  arrExpr: ts.ArrayLiteralExpression,
): unknown[] {
  const arr: unknown[] = [];
  arrExpr.elements.forEach((el) => {
    if (ts.isObjectLiteralExpression(el)) {
      const item = convertObjectLiteralExpressionToCode(el);
      arr.push(item);
    }
    if (ts.isStringLiteralLike(el)) {
      const item = convertStringLikeToCode(el);
      arr.push(item);
    }
    if (ts.isArrayLiteralExpression(el)) {
      const item = convertArrayLiteralExpressionToCode(el);
      arr.push(item);
    }
  });
  return arr;
}

function convertObjectLiteralExpressionToCode(
  objExpr: ts.ObjectLiteralExpression,
): Record<string, unknown> {
  const obj: Record<string, unknown> = {};
  objExpr.properties.forEach((prop) => {
    if (ts.isPropertyAssignment(prop)) {
      const key = prop.name.getText();

      let val;
      if (ts.isObjectLiteralExpression(prop.initializer)) {
        val = convertObjectLiteralExpressionToCode(prop.initializer);
      }
      if (ts.isStringLiteralLike(prop.initializer)) {
        val = convertStringLikeToCode(prop.initializer);
      }
      if (ts.isArrayLiteralExpression(prop.initializer)) {
        val = convertArrayLiteralExpressionToCode(prop.initializer);
      }
      if (ts.isNumericLiteral(prop.initializer)) {
        val = convertNumericalLiteralToCode(prop.initializer);
      }
      if (
        prop.initializer.kind === ts.SyntaxKind.TrueKeyword ||
        prop.initializer.kind === ts.SyntaxKind.FalseKeyword
      ) {
        val = convertBooleanLiteralToCode(
          prop.initializer as ts.BooleanLiteral,
        );
      }

      if (key && typeof val !== 'undefined') {
        obj[key] = val;
      }
    }
  });
  return obj;
}

function relativePath(from: string, to: string) {
  // On Windows relative() outputs backslashes. As the path should always contain normal slashes, replace them.
  return relative(from, to).replace(/\\/g, '/');
}
